import json
import math
import os
import time
from pathlib import Path
import pandas as pd
import requests as rq
from dotenv import load_dotenv
from tqdm import tqdm
load_dotenv()
CLIENT_ID = os.getenv("SPOTIFY_CLIENT_ID")
CLIENT_SECRET = os.getenv("SPOTIFY_CLIENT_SECRET")
OUTPUT_DIR = Path("data/raw_responses")
BATCH_SIZE = 50
def get_access_token() -> str:
auth_url = "https://accounts.spotify.com/api/token"
try:
auth_response = rq.post(
auth_url, {"grant_type": "client_credentials", "client_id": CLIENT_ID, "client_secret": CLIENT_SECRET}
)
auth_response.raise_for_status()
return auth_response.json()["access_token"]
except Exception as e:
print(f"Failed to get access token: {e}")
raise
def main() -> None:
df = pd.read_csv("data/tracks_features.csv")
track_ids = df["id"].dropna().unique().tolist()
total_tracks = len(track_ids)
print(f"Found {total_tracks} tracks.")
token = get_access_token()
headers = {"Authorization": f"Bearer {token}"}
num_batches = math.ceil(total_tracks / BATCH_SIZE)
for i in tqdm(range(num_batches), desc="Fetching batches"):
batch_file = OUTPUT_DIR / f"batch_{i}.json"
# Skip if already processed
if batch_file.exists():
continue
start_idx = i * BATCH_SIZE
end_idx = start_idx + BATCH_SIZE
batch_ids = track_ids[start_idx:end_idx]
ids_param = ",".join(batch_ids)
url = f"https://api.spotify.com/v1/tracks?ids={ids_param}"
while True:
try:
time.sleep(2)
response = rq.get(url, headers=headers)
if response.status_code == 200:
data = response.json()
with batch_file.open("w") as f:
json.dump(data, f)
break
if response.status_code == 429:
retry_after = int(response.headers.get("Retry-After", 5))
tqdm.write(f"Rate limited. Sleeping for {retry_after} seconds.")
time.sleep(retry_after + 1)
elif response.status_code == 401:
tqdm.write("Token expired. Refreshing...")
token = get_access_token()
headers = {"Authorization": f"Bearer {token}"}
else:
tqdm.write(f"Error {response.status_code} for batch {i}: {response.text}")
# Log error and skip this batch to avoid blocking
error_file = OUTPUT_DIR / f"error_batch_{i}.txt"
with error_file.open("w") as f:
f.write(f"Status: {response.status_code}\n{response.text}")
break
except rq.exceptions.RequestException as e:
tqdm.write(f"Request exception: {e}")
time.sleep(5)
if __name__ == "__main__":
main()Datenbericht
Rohdaten
| Datensatz Name | Quelle | Speicherort |
|---|---|---|
| Spotify 1.2M+ Songs | Kaggle | data/tracks_features.csv |
| Spotify API (GET /tracks) | Spotify API | data/raw_responses/batch_{i}.json |
| Genius Song Lyrics | Kaggle | data/song_lyrics.csv |
Details Spotify Songs (Kaggle & API)
Die Grundbasis dieser Arbeit ist das “Spotify 1.2M+ Songs” Dataset von Rodolfo Figueroa auf kaggle.com. Enthalten ist eine CSV-Datei, tracks_features.csv, welche Daten zu über 1.2 Millionen Songs auf Spotify von der Spotify Developer API enthalten. Spezifisch von den /tracks und /audio-features endpoints.
Audio Features sind Informationen über ein Lied, welche zum einen direkt dem Lied entnommen wurden, wie z.B. duration_ms, tempo und key, sowohl auch Werte welche durch eine Analyse von Spotify kalkuliert wurden (z.B. speechiness, valence und energy).
Leider fehlt in diesem Dataset eine wichtige Kennzahl, popularity. Deswegen haben wir diese Zahlen selber anhand der Songs im Dataset von der API entnommen.
Mithilfe eines API-Accounts und dem folgenden Python Skript haben wir dies erreicht. Es..
- liest alle Spotify Track IDs des
tracks_features.csvein - fragt den API endpoint
tracksin Batches von 50 Tracks ab - speichert die Responses pro Batch im directory
data/raw_responsesalsbatch_{i}.jsonab.
Danach hatten wir 24’081 JSON files, die wir mit dem tracks_features.csv Dataset joinen mussten. Dies haben wir mit dem folgenden Skript vollendet. Aus Performance Gründen haben wir das orjson Python package und eingebautes Multiprocessing verwendet:
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
import orjson
# load all json files in data/raw_responses and combine into a single dataframe
data_path = Path("../data/raw_responses")
json_files = list(data_path.glob("*.json"))
print(f"Found {len(json_files)} JSON files.")
def process_file(file_path: Path) -> list:
data = orjson.loads(file_path.open("rb").read())
tracks = []
for t in data.get("tracks", []):
t.pop("available_markets", None)
tracks.append(t)
return tracks
print("Processing files in parallel...")
tracks_all = []
with ThreadPoolExecutor() as executor:
results = executor.map(process_file, json_files)
for tracks in tqdm(results, total=len(json_files)):
tracks_all.extend(tracks)
df = pd.DataFrame(tracks_all)
print(f"Total tracks loaded: {len(df)}")Found 24081 JSON files.
Processing files in parallel...
100%|██████████| 24081/24081 [02:19<00:00, 172.81it/s]
Total tracks loaded: 1204025
df_features = pd.read_csv("../data/tracks_features.csv", keep_default_na=False)
print(f"Tracks geladen: {len(df_features)}")
df_combined = df_features.merge(df[["id", "popularity"]], on="id", how="left")
print(f"Kombinierte shape: {df_combined.shape}")
# How many rows with missing popularity?
missing_popularity = df_combined["popularity"].isna().sum()
print(f"Zeilen mit fehlender popularity: {missing_popularity}")
# Export to CSV
output_path = Path("../data/full_tracks_features.csv")
df_combined.to_csv(output_path, index=False)Tracks geladen: 1204025
Kombinierte shape: (1204025, 25)
Zeilen mit fehlender popularity: 0
Nun hatten wir die popularity Metrik für alle 1’204’025 Tracks hinzugefügt und als eine neue Datei full_tracks_features.csv abgepeichert.
Rechtliche Aspekte
- Kaggle-Dataset: Der verwendete Datensatz weist keine explizite eigene Lizenz aus. Laut Beschreibung des Autors wurden die enthaltenen Daten über die Spotify Web API erhoben.
- Spotify API: Die Nutzung der Spotify Web API unterliegt den Spotify Developer Terms (Version 10, gültig seit 15. Mai 2025).
- Fair Use: Der Zugriff auf die Spotify API erfolgt unter Einhaltung der vorgegebenen Rate Limits. Die API-Zugangsdaten werden sicher verwaltet und nicht öffentlich zugänglich gemacht.
- Datenschutz: Es werden keine personenbezogenen Daten von Spotify-Nutzern verarbeitet.
- Urheberrecht: Es werden keine urheberrechtlich geschützte Inhalte bezogen.
- Machine-Learning: Die eingesetzten Machine-Learning-Verfahren dienen ausschliesslich der statistischen Modellierung und Analyse dieser Metadaten im Rahmen eines Nicht-kommerziellen Schulprojekts.
Datenkatalog
| Index | Name | Datentyp | Werte | Beschreibung |
|---|---|---|---|---|
| 1 | id | string | Format: base-62 | Die Spotify-ID für den Track |
| 2 | name | string | - | Der Name des Tracks |
| 3 | album | string | - | Das Album, auf dem der Track erscheint. |
| 4 | album_id | string | Format: base-62 | Die Spotify-ID für das Album |
| 5 | artists | Liste von strings | - | Die Künstler, die den Track performt haben. |
| 6 | artist_id | Liste von strings | Format: base-62 | Die Spotify-ID für die Künstler |
| 7 | track_number | integer | Wertebereich: 1 - 50 | Die Nummer des Tracks auf dem Album. |
| 8 | disc_number | integer | Wertebereich: 1 - 13 | Die Disc-Nummer, auf dem der Track erscheint |
| 9 | explicit | boolean | true = Ja false = Nein oder unbekannt |
Ob der Track explizite Texte enthält |
| 10 | danceability | float | Wertebereich: 0 - 1 | Tanzbarkeit beschreibt, wie geeignet ein Track zum Tanzen ist, basierend auf einer Kombination musikalischer Elemente. 0.0 → am wenigsten tanzbar, 1.0 → am tanzbarsten |
| 11 | energy | float | Wertebereich: 0 - 1 | Wahrnehmungsmass für Intensität und Aktivität dar, typischerweise fühlen sich energiegeladene Tracks schnell, laut und geräuschvoll an |
| 12 | key | integer | Wertebereich: -1 - 11 | Die Tonart, in der sich der Track befindet, basierend auf Standard-Pitch-Class-Notation, Wert -1 = keine Tonart erkannt |
| 13 | loudness | float | Wertebereich: -60 - 0 Einheit: Dezibel (dB) |
Die Gesamtlautstärke eines Tracks in Dezibel (dB) |
| 14 | mode | integer | 1 = Major, 0 = Minor | Gibt die Tonalität (Dur oder Moll) eines Tracks an |
| 15 | speechiness | float | Wertebereich: 0 - 1 | Erkennt das Vorhandensein von gesprochenen Worten in einem Track |
| 16 | acousticness | float | Wertebereich: 0 - 1 | Konfidenzmass ob der Track akustisch ist |
| 17 | instrumentalness | float | Wertebereich: 0 - 1 | Sagt voraus, ob ein Track keinen Gesang enthält |
| 18 | liveness | float | Wertebereich: 0 - 1 | Erkennt die Anwesenheit eines Publikums in der Aufnahme |
| 19 | valence | float | Wertebereich: 0 - 1 | Beschreibt die musikalische Positivität, die von einem Track vermittelt wird |
| 20 | tempo | float | Einheit: beats per minute (BPM) | Das geschätzte Gesamttempo eines Tracks in Schlägen pro Minute (BPM) |
| 21 | duration_ms | float | Einheit: Milisekunden (ms) | Die Dauer des Tracks in Millisekunden. |
| 22 | time_signature | integer | Wertebereich: 3 - 7 | Eine geschätzte Taktart, gibt wie viele Schläge in jedem Takt enthalten sind |
| 23 | year | integer | Format: YYYY | Das Jahr des Veröffentlichungsdatums des Tracks |
| 24 | release_date | string | Formate: YYYY, YYYY-MM, YYYY-MM-DD |
Das Datum, an dem das Album erstmals veröffentlicht wurde. Präzision und somit Format variiert |
| 25 | popularity | integer | Wertebereich: 0 - 100 | Die Popularität des Tracks, basiert haupstächlich auf Gesamtzahl der Wiedergaben |
(Für zusätzliche Informationen siehe Spotify API Dokumentation)
Datenqualität
Diese Datenanalyse wurde in Python 3.14 mit den pandas und plotly Paketen durchgeführt. Mehr Details können auf dem Repository im pyproject.toml gefunden werden.
| Anzahl Spalten | 25 |
| Anzahl Zeilen | 1’204’025 |
| Anzahl leerer Zellen | 0 |
| Anteil (%) leerer Zellen | 0% |
| Anzahl duplizierter Zeilen | 0 |
| Anteil (%) duplizierter Zeilen | 0% |
Leere Zellen:
Da die Strings im CSV nicht wrapped sind mit Anführungszeichen, hat Pandas die Datei am Anfang der Analyse falsch geladen. Denn es gibt ein Album vom Künstler Gupi mit dem Namen “None”. Pandas hat dies standardmässig als einen fehlenden Wert interpretiert. Um dies zu lösen haben wir für die Analyse und das Weiterverarbeiten von nun an die Datei folgendermassen geladen:
df = pd.read_csv(Path("../data/full_tracks_features.csv"), keep_default_na=False, na_values=[""])
Duplizierte Zeilen
Überaschenderweise gibt es keine duplizierte Zeilen, obwohl (wie die Dokumentation1 erwähnt) die id Spalte einen Track nicht eindeutig identifiziert.
Verteilung popularity
Die Verteilung der Popularität ist extrem rechtsschief. Wie die folgende Grafik zeigt, hat ein massiver Anteil der Tracks im Datensatz eine Popularität von 0. Da die Anzahl der Tracks mit Popularität 0 die restlichen Werte bei weitem übersteigt, wird hier eine logarithmische Skala für die y-Achse verwendet.
Diese grosse Menge an Tracks mit dem Wert 0 deutet auf viele inaktive, sehr alte oder extrem nischenhafte Songs hin, die auf der Plattform kaum oder gar nicht gehört werden. Für die spätere Modellierung ist dies ein entscheidender Faktor, da wir entscheiden müssen, ob wir diese “toten” Datenpunkte behalten oder filtern wollen.
Code
from pathlib import Path
import pandas as pd
import plotly.express as px
import plotly.io as pio
pio.renderers.default = "plotly_mimetype+notebook_connected"
df = pd.read_csv(Path("../data/full_tracks_features.csv"), keep_default_na=False, na_values=[""])
fig = px.histogram(x=df["popularity"], log_y=True, title="Verteilung der Popularität (log)", labels={"x": "Popularity"})
fig.update_layout(yaxis_title="Anzahl Tracks", xaxis_title="Popularity (0-100)", showlegend=False, yaxis={"dtick": 1})
fig.update_traces(marker_line_color="black", marker_line_width=1)
fig.show()
pop_0_count = (df["popularity"] == 0).sum()
pop_gt_0_count = (df["popularity"] > 0).sum()
print(f"Anzahl Tracks mit popularity 0: {pop_0_count}")
print(f"Anzahl Tracks mit popularity > 0: {pop_gt_0_count}")Anzahl Tracks mit popularity 0: 729167
Anzahl Tracks mit popularity > 0: 474858
Verteilung duration_ms
Die Verteilung der Dauer der Tracks zeigt ebenfalls interessante Muster. Auch hier verwenden wir eine logarithmische Skala für die y-Achse.
Es gibt eine Anzahl an Tracks, die sehr kurz sind (unter 30 Sekunden), konkret 7’709 Stück. Dies sind oft Soundeffekte, Intros oder Interludes. Obwohl dies im Vergleich zur Gesamtmenge ein kleiner Anteil ist, filtern wir diese später heraus, um die Datenqualität zu erhöhen. Zusätzlich zeigt die Tabelle unterhalb der Grafik einige Extremwerte am anderen Ende des Spektrums: Tracks mit einer Dauer von über 90 Minuten.
Code
fig = px.histogram(
x=df["duration_ms"] / 1000 / 60, log_y=True, title="Verteilung der Dauer (log)", labels={"x": "Dauer (min)"}
)
fig.update_layout(yaxis_title="Anzahl Tracks", xaxis_title="Dauer (min)", showlegend=False, yaxis={"dtick": 1})
fig.update_traces(marker_line_color="black", marker_line_width=1, xbins={"size": 1})
fig.show()
under_30s_count = (df["duration_ms"] <= 30000).sum()
print(f"Anzahl Tracks 30s oder kürzer: {under_30s_count}")
print("Tracks mit Dauer über 90 Minuten:")
long_tracks = df[df["duration_ms"] > 90 * 60 * 1000].copy()
long_tracks["duration_readable"] = pd.to_datetime(long_tracks["duration_ms"], unit="ms").dt.strftime("%H:%M:%S")
display(long_tracks[["name", "artists", "duration_readable", "speechiness"]])